适用场景
飞书 CLI 内置了一套扩展机制,让你在不修改 CLI 源码的情况下,替换或增强 CLI 的核心行为。
如果你是企业内部开发者或 ISV,希望在自己的 Agent 或应用中直接内嵌飞书 CLI 能力,并需要:
-
从自己的凭证系统(数据库、Vault、配置中心)获取 AppID / Token 等
-
对所有 HTTP 请求统一加监控、日志,或改写请求目标等
-
给 Agent 划能力边界,只让它用特定身份(用户或应用)调用特定命令
-
统一审计所有命令的执行情况
-
写类命令接审批 / 限流后才放行
-
进程启动时初始化资源、退出前刷盘清理
你只需要:写一个 Go 包,实现指定接口,在 wrapper main 里 import 它,重新编译——就得到一个行为完全不同的增强版 CLI。

飞书 CLI 提供六个扩展点,分为基础能力扩展和命令行为扩展两大类。基础能力扩展改变 CLI 底层的凭证获取和网络通信方式;命令行为扩展控制命令的可用范围、运行时行为和进程生命周期。
| 扩展点 | 分层定位 | 核心能力 | 使用场景 |
|---|---|---|---|
| Credential | 基础能力 · 凭证层 | 自定义凭证来源,让 CLI 从数据库、Vault、配置中心等任意来源获取 AppID / Token | 内嵌 CLI 到自有系统,需要对接企业凭证管理 |
| Transport | 基础能力 · 网络层 | 拦截 CLI 每个 HTTP 请求,在请求前后注入 Header、改写目标、记录日志、上报监控 | 统一网络监控、日志审计、请求路由改写 |
| Restrict | 命令行为 · 裁剪层 | 声明命令白名单 / 黑名单 / 风险上限 / 身份限定,启动时将不合规命令改写为桩 | 给 Agent 划能力边界,只暴露安全的命令子集 |
| Observer | 命令行为 · Hook 层 | 命令前/后旁观,只看不拦;panic 安全,不影响命令执行 | 审计日志、埋点、命令监控(含被拒命令) |
| Wrap | 命令行为 · Hook 层 | 洋葱模式中间件,可在命令前后做事,也可直接拦截(返回 AbortError) | 审批拦截、限流、运行时 token 注入 |
| On | 命令行为 · 生命周期层 | 进程级 Startup / Shutdown 处理器,启动时初始化、退出前清理 | 初始化连接池、退出前刷审计日志 |
扩展 1:Credential — 凭证来源扩展
它是什么
Credential 扩展让你自定义凭证的来源。CLI 默认会从环境变量(LARKSUITE_CLI_APP_ID、LARKSUITE_CLI_APP_SECRET、LARKSUITE_CLI_USER_ACCESS_TOKEN、LARKSUITE_CLI_TENANT_ACCESS_TOKEN)或本地 keychain 读取凭证。通过注册自定义 Provider,你可以让 CLI 从数据库、Vault、配置中心等任意来源获取凭证。本文为了直观,就采取从本地读取凭证来进行演示。
怎么用
第 1 步:创建项目
mkdir my-cli && cd my-cli
go mod init my-cli
第 2 步:编写 Provider
创建 mycred/mycred.go,实现 credential.Provider 接口:
package mycred
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/larksuite/cli/extension/credential"
)
const (
appID = "cli_aXXXXXXXXXXXXXXXX" // 替换为真实 AppID
appSecret = "XXXXXXXXXXXXXXXXXXXXXXXX" // 替换为真实 AppSecret
)
type Provider struct{}
func (p *Provider) Name() string { return "mycred" }
// ResolveAccount 返回应用凭证
// 返回 &Account{} → 命中,CLI 用这组凭证
// 返回 nil, nil → 跳过,交给下一个 Provider
// 返回 nil, err → 报错,终止
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
// 这里替换成你自己的逻辑:查数据库、调 Vault、读配置中心...
return &credential.Account{
AppID: appID,
AppSecret: appSecret,
Brand: credential.BrandFeishu,
DefaultAs: credential.IdentityUser,
}, nil
}
// ResolveToken 返回访问令牌
// 重要:一旦你的 Provider 接管了 Account,Token 也由你负责。
// 返回 nil, nil 不会 fallback 到默认换 Token 逻辑,而是直接报错。
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
switch req.Type {
case credential.TokenTypeTAT:
// bot 身份:用 AppID + AppSecret 向飞书换取 tenant_access_token
token, err := exchangeTenantAccessToken(appID, appSecret)
if err != nil {
return nil, err
}
return &credential.Token{Value: token, Source: "mycred:tat"}, nil
case credential.TokenTypeUAT:
// user 身份:从任意来源获取 user_access_token
// 例如:从数据库查询、从 Redis 缓存读取、从 OAuth 回调获取、
// 从配置文件读取,甚至直接写死一个用于测试...
uat := getUserAccessToken()
return &credential.Token{Value: uat, Source: "mycred:uat"}, nil
}
return nil, nil
}
// getUserAccessToken 从你的来源获取 UAT
// 这里你可以对接任何存储:数据库、Redis、文件、环境变量等
func getUserAccessToken() string {
// 示例:从环境变量获取
// return os.Getenv("MY_USER_ACCESS_TOKEN")
// 示例:从数据库查询
// return db.Query("SELECT uat FROM tokens WHERE user_id = ?", userID)
// 示例:写死一个用于本地测试(从 lark-cli auth login 后获取)
return "u-XXXXXXXX"
}
// exchangeTenantAccessToken 用 AppID + AppSecret 向飞书换取 tenant_access_token
func exchangeTenantAccessToken(appID, appSecret string) (string, error) {
body, _ := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
})
resp, err := http.Post(
//此处以feishu Brand作为演示,如果为LARK品牌host切换为:open.larksuite.com
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
"application/json", bytes.NewReader(body),
)
if err != nil {
return "", fmt.Errorf("exchange tat: %w", err)
}
defer resp.Body.Close()
var result struct {
Code int `json:"code"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Code != 0 {
return "", fmt.Errorf("exchange tat failed, code=%d", result.Code)
}
return result.TenantAccessToken, nil
}
func init() {
credential.Register(&Provider{})
}
第 3 步:编写 wrapper main
创建 main.go:
package main
import (
"os"
"github.com/larksuite/cli/cmd"
_ "my-cli/mycred" // blank import 触发 init() → 注册你的 Provider
)
func main() {
os.Exit(cmd.Execute())
}
第 4 步:编译验证
go mod tidy
go build -o my-lark-cli .
# 用 --dry-run 验证凭证来源(不会真正发请求)
./my-lark-cli api GET /open-apis/calendar/v4/calendars --dry-run
输出:
{
"appId": "cli_a1234567890",
"as": "user"
}
appId 显示的是你写死的值,说明自定义 Provider 生效了。
原理
Credential 采用链式数组存储多个 Provider,按注册顺序依次查询,第一个返回非 nil 的胜出:
import 顺序: mycred → env
注册结果: providers = [mycred, env]
CLI 需要凭证时:
┌─ mycred.ResolveAccount()
│ 查到了? → 返回 Account → 命中! 后面全跳过
│ 没查到? → 返回 nil → 继续
│
├─ env.ResolveAccount()
│ 环境变量有? → 返回 Account → 命中
│ 没有? → 返回 nil → 继续
│
└─ defaultAcct (keychain 兜底)
延迟执行:Provider 在启动时只是注册,不会立即被调用。只有当用户执行的命令真正需要凭证时,才会触发链式遍历。结果通过 sync.Once 缓存,整个进程只解析一次。
核心源码
| 文件 | 作用 |
|---|---|
extension/credential/types.go |
Provider 接口、Account / Token 类型定义 |
extension/credential/registry.go |
Register() 追加到全局数组,Providers() 返回快照 |
extension/credential/env/env.go |
内置实现:从环境变量读取凭证 |
扩展 2:Transport — HTTP 请求拦截扩展
它是什么
Transport 扩展让你拦截 CLI 发出的每一个 HTTP 请求。你可以在请求发出前修改 Header、改写请求目标(Host / URL)、打印日志,在请求完成后记录耗时、上报监控。
怎么用
第 1 步:编写 Interceptor
创建 tracing/tracing.go,实现 transport.Provider 和 transport.Interceptor 接口:
package tracing
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/larksuite/cli/extension/transport"
)
type Provider struct{}
func (p *Provider) Name() string { return "tracing" }
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
return &tracingInterceptor{}
}
type tracingInterceptor struct{}
// PreRoundTrip 在每个请求发出前被调用
// 返回的函数在请求完成后被调用
func (i *tracingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
// ===== 请求前 =====
start := time.Now()
fmt.Fprintf(os.Stderr, "🚀 请求开始: %s %s\n", req.Method, req.URL)
// ===== 请求后(返回的闭包)=====
return func(resp *http.Response, err error) {
elapsed := time.Since(start)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ 请求失败: %s %s err=%v (耗时 %v)\n",
req.Method, req.URL, err, elapsed)
} else {
fmt.Fprintf(os.Stderr, "✅ 请求完成: %s %s 状态码=%d (耗时 %v)\n",
req.Method, req.URL, resp.StatusCode, elapsed)
}
}
}
func init() {
transport.Register(&Provider{})
}
第 2 步:在 main.go 中引入
package main
import (
"os"
"github.com/larksuite/cli/cmd"
_ "my-cli/tracing" // blank import 注册 Transport Interceptor
_ "github.com/larksuite/cli/extension/credential/env"
)
func main() {
os.Exit(cmd.Execute())
}
第 3 步:编译验证
go build -o my-lark-cli .
验证 UAT(user 身份):
./my-lark-cli api GET /open-apis/calendar/v4/calendars --as user
输出:
🚀 请求开始: GET https://open.feishu.cn/open-apis/authen/v1/user_info
✅ 请求完成: GET https://open.feishu.cn/open-apis/authen/v1/user_info 状态码=200 (耗时 644ms)
🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET https://open.feishu.cn/open-apis/calendar/v4/calendars 状态码=200 (耗时 1070ms)
{ "code": 0, "data": { "calendar_list": [...] } }
验证 TAT(bot 身份):
./my-lark-cli api GET /open-apis/calendar/v4/calendars --as bot
输出:
🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET https://open.feishu.cn/open-apis/calendar/v4/calendars 状态码=200 (耗时 736ms)
{ "code": 0, "data": { ... } }
原理
Transport 采用单 Provider 模式,注册后会被包装为一个 HTTP 中间件,插入到 CLI 的请求链中:
CLI 发 HTTP 请求
│
▼
你的 PreRoundTrip(req) ← 请求前: 加 Header / 改写 Host / 打日志 / 开始计时
│ 返回一个 postFn
▼
CLI 内置 transport 链 ← Retry → SecurityHeader → 实际网络请求
│
▼
postFn(resp, err) ← 请求后: 记录耗时 / 上报监控
CLI 在构建 HTTP Client 时,会调用 wrapWithExtension() 把你的 Interceptor 包装成中间件。之后 CLI 发出的每一个请求(包括换 Token、调业务 API)都会经过这个中间件。
安全保障:CLI 内置安全层会统一重写基础安全 Header(如 X-Cli-*、User-Agent)。Authorization 不属于这层统一覆盖:它由请求构建阶段设置;如果发生跨 host redirect,会在 redirect 策略中被剥离。因此你可以添加自定义 Header,但不要依赖 Transport 扩展去覆盖凭证头。
核心源码
| 文件 | 作用 |
|---|---|
extension/transport/types.go |
Provider / Interceptor 接口定义 |
extension/transport/registry.go |
Register() 注册单个 Provider(last-write-wins) |
扩展 3:Restrict — 命令裁剪(启动期)
它是什么
Restrict 让你声明一条或多条"裁剪规则"(Rule):哪些命令能用、哪些不能、最高到什么风险、限定什么身份。框架启动时把整棵命令树过一遍——不符合规则的命令被改写成桩:从 help 里隐藏,一旦执行就返回 command_denied。对命令分发来说它们还是合法命令,只是一跑就报错。
怎么用
platform.NewPlugin("secaudit", "1.0.0").
Restrict(&platform.Rule{
Name: "agent-docs-readonly",
Allow: []string{"docs/**"}, // glob,"/" 是分隔符,"**" 匹配多段
MaxRisk: "read", // 风险上限:read | write | high-risk-write
}).
MustBuild()
编译后可以查看当前生效的裁剪规则:
$ ./my-lark-cli config policy show
{
"source": "plugin",
"source_name": "secaudit",
"rules": [{"name": "agent-docs-readonly", "allow": ["docs/**"], "max_risk": "read", "allow_unannotated": false}],
"denied_paths": 258
}
Rule 的字段
| 字段 | 含义 | 例子 |
|---|---|---|
Name |
规则名(必填)。config policy show 输出和 envelope 的 rule_name 会带上它,方便排查 |
"agent-docs-readonly" |
Description |
可选描述,仅供 config policy show 展示 |
"Agent 只读 docs 命令" |
Allow |
白名单 glob,命中即通过;空表示"不限制路径" | ["docs/**", "im/+messages-send"] |
Deny |
黑名单 glob,命中即拒绝(优先于 Allow) | ["**/+*-delete"] |
MaxRisk |
风险等级上限(read / write / high-risk-write)。也可用常量 platform.RiskRead / RiskWrite / RiskHighRiskWrite |
"read" 排除所有写类命令 |
Identities |
限定身份白名单(user / bot)。空表示不按身份过滤 | []platform.Identity{"user"} 排除 bot 类命令 |
AllowUnannotated |
是否放行未标注 risk_level 的命令。默认 false(fail-closed):未标注命令直接拒并报 risk_not_annotated;改 true 则这类命令跳过 MaxRisk 检查 |
true(把第三方 plugin 的老命令平滑接入) |
多条规则怎么组合(容易踩坑):
-
单条 Rule 内的四个维度(Allow / Deny / MaxRisk / Identities)是 AND:必须同时满足才放行。
-
多条 Rule 之间是 OR:命中任意一条的全部维度即放行。同一个 plugin 可以多次调
Restrict,每条是一个独立授权。 -
每条 Rule 的
Deny只在本条内生效,不能否决另一条的 Allow。要全局禁某命令,得在每条 Rule 里都 Deny(或都不 Allow)。 -
只接受一个 plugin 贡献规则:启动期发现 2 个以上不同 plugin 都贡献了 Rule,直接 abort 整个 CLI(
plugin_conflict/multiple_restrict_plugins),不做静默合并。 -
~/.lark-cli/policy.yml也能写规则,但优先级最低:只要程序里有插件自带了规则,这个 yaml 就不会被加载。
多条 Rule 示例
不同命令组要不同 risk / 身份:
platform.NewPlugin("secaudit", "1.0.0").
Restrict(&platform.Rule{ // docs 只读、限 user 身份
Name: "docs-readonly",
Allow: []string{"docs/**"},
MaxRisk: platform.RiskRead,
Identities: []platform.Identity{"user"},
}).
Restrict(&platform.Rule{ // im 可写、user+bot 都允许
Name: "im-write",
Allow: []string{"im/**"},
MaxRisk: platform.RiskWrite,
Identities: []platform.Identity{"user", "bot"},
}).
MustBuild()
命令命中任意一条的全部维度即放行;全都没命中时 envelope 报 no_matching_rule(只有一条 Rule 时仍报各自具体的 reason_code)。
诊断:命令为什么被拒?
两个只读、不调任何 API 的诊断命令,排查时先用它们:
| 命令 | 关注 | 数据 |
|---|---|---|
config policy show |
"当前生效裁剪规则是啥" | Rule + denied_paths + source |
config plugins show |
"都装了哪些 plugin、各自贡献啥" | 完整 plugin 清单 + 各自 hook 列表 |
config policy show
查看 CLI 当前生效的裁剪规则、来源和被裁路径数。适用于:排查"为什么我的命令被拒"、验证 plugin 是否成功贡献了 Rule、检查用户 yaml 是否被 plugin 影子覆盖。
$ ./my-cli config policy show
{
"source": "plugin",
"source_name": "secaudit",
"rules": [{
"name": "agent-docs-readonly",
"description": "",
"allow": ["docs/**"],
"deny": null,
"max_risk": "read",
"identities": null,
"allow_unannotated": false
}],
"denied_paths": 258
}
| 字段 | 含义 |
|---|---|
source |
当前生效来源:"plugin" / "yaml" / "none" |
source_name |
来源标签:仅当 source 为 plugin 时是插件名;yaml 与 none 来源下为空(定位 yaml 配置路径请查 ~/.lark-cli/policy.yml) |
rules |
已决议、已落地的 Rule 列表(单条时数组长度为 1) |
denied_paths |
被裁的命令路径总数(含父组聚合) |
config plugins show
列出所有成功装载的 plugin 及其完整贡献:能力声明、贡献的 Rule、注册的 Hook(Observer / Wrapper / Lifecycle)。适用于:拿到别人编译的二进制想知道"到底装了啥"、排查"为啥我的命令前后多了一堆日志"、审计某 plugin 注册了哪些 Wrapper / Lifecycle、验证 plugin 是否完整安装。
$ ./my-cli config plugins show
{
"plugins": [
{
"name": "secaudit",
"version": "1.0.0",
"capabilities": { "restricts": true, "failure_policy": "FailClosed" },
"rules": [{
"name": "agent-docs-readonly",
"allow": ["docs/**"], "deny": null,
"max_risk": "read", "identities": null, "allow_unannotated": false
}],
"hooks": {
"count": 5,
"observers": [
{ "name": "secaudit.audit-pre", "when": "Before" },
{ "name": "secaudit.audit-post", "when": "After" }
],
"wrappers": [ { "name": "secaudit.approval" } ],
"lifecycle": [
{ "name": "secaudit.init", "event": "Startup" },
{ "name": "secaudit.flush", "event": "Shutdown" }
]
}
}
],
"total": 1
}
如何辨认 hook 归属: 框架在注册时把 hookName 自动加上 plugin 名前缀,分隔符是 "."。所以 secaudit.boot 就是 secaudit plugin 注册的 boot hook。plugin 名禁用 ".",分隔无歧义。
扩展 4:Observer — 旁观 Hook(只看不拦)
它是什么
Observer 让你注册一个"观察者",命令执行前(Before)或后(After)跑你的代码。它不参与主流程、不能拦命令,它出错或 panic 都不影响命令本身,常用于审计、埋点、监控。
怎么用
platform.NewPlugin("secaudit", "1.0.0").
// 命令运行前:记审计
Observer(platform.Before, "audit-pre", platform.All(),
func(ctx context.Context, inv platform.Invocation) {
log.Printf("pre %s denied=%v", inv.Cmd().Path(), inv.DeniedByPolicy())
}).
// 命令运行后:记结果
Observer(platform.After, "audit-post", platform.All(),
func(ctx context.Context, inv platform.Invocation) {
log.Printf("post %s err=%v", inv.Cmd().Path(), inv.Err())
}).
FailOpen().
MustBuild()
Selector:决定 Hook 在哪些命令上生效
platform.All() 是"所有命令"。还有按维度筛选的 Selector:
platform.ByDomain("docs") // docs 域所有命令
platform.ByExactRisk("write") // 风险等级精确为 write 的命令
platform.ByWrite() // 风险为 write 或 high-risk-write
platform.ByReadOnly() // 风险为 read 的命令
platform.ByIdentity("bot") // 限定身份
用 .And() / .Or() / .Not() 组合:
// im 域,但排除 +chats-list
platform.ByDomain("im").And(platform.ByCommandPath("im/+chats-list").Not())
// docs 或 im 域(任一命中即可)
platform.ByDomain("docs").Or(platform.ByDomain("im"))
Observer 的三条边界
-
不能短路:返回值会被丢弃,改变不了命令是否执行。
-
panic 安全:框架用
recover包着,Observer panic 不会冒到外层。 -
被裁命令也跑:
inv.DeniedByPolicy()为 true 时也会触发(这是 Observer 跟 Wrapper 最大的不同),方便审计被拒命令。
Invocation:Observer 和 Wrap 的运行期上下文
| 方法 | 返回 / 含义 |
|---|---|
Cmd() CommandView |
命令视图,可调 .Path() / .Risk() / .Identities() / .Domain() |
Args() []string |
本次调用的位置参数(不含 flag) |
Started() time.Time |
命令开始时间,可在 After 里算耗时 |
Err() error |
仅 After 有意义:命令 RunE 的返回错误(含 AbortError) |
DeniedByPolicy() bool |
是否被裁(user-layer Rule 或 strict-mode 都算) |
DenialLayer() string |
裁的来源:"policy" / "strict_mode";没被裁时为空 |
DenialPolicySource() string |
policy 层 Rule 的来源标签:plugin:<name> / yaml |
扩展 5:Wrap — 中间件 Hook(能拦能改)
它是什么
Wrap 是"洋葱模式"中间件,像洋葱一样包住命令的真正逻辑。你拿到一个 next 函数,在调用 next 之前 / 之后做事,也可以直接拦下(返回 AbortError)。常用于审批、限流、注入 token。
Wrap 链按注册顺序左→右组合,外层先跑。
怎么用
经典场景:写类命令必须过审批。
.Wrap("approval", platform.ByWrite(),
func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
if !approvalClient.Check(ctx, inv.Cmd().Path()) {
return &platform.AbortError{
HookName: "approval",
Reason: "write requires approval",
}
}
return next(ctx, inv)
}
})
效果:
{
"ok": false,
"error": {
"type": "hook",
"message": "hook \"secaudit.approval\" aborted: write requires approval",
"detail": {
"hook_name": "secaudit.approval",
"reason": "write requires approval",
"reason_code": "aborted"
}
}
}
多个 Wrapper 的组合顺序
注册顺序左→右,外层先跑:
.Wrap("logging", All(), loggingWrapper).
.Wrap("approval", ByWrite(), approvalWrapper).
.Wrap("ratelimit", All(), ratelimitWrapper)
logging → approval → ratelimit → 原 RunE
Observer vs Wrap:到底用哪个?
两者长得像(都靠 Invocation 拿上下文),但语义差很远。一句话:只想"看"用 Observer,要"拦 / 改"用 Wrap。
| 维度 | Observer(旁观) | Wrapper(中间件) |
|---|---|---|
| 能拦下命令吗 | 不能,返回值被丢弃 | 能,返回 AbortError 即短路 |
| 能改命令是否执行吗 | 不能 | 能 |
| panic 怎么处理 | 框架 recover,不影响命令 | 框架 recover,不污染外层 |
| 被裁(denied)的命令 | 照样跑(方便审计被拒命令) | 不跑 |
| 触发时机 | Before / After 两个点 | 包在命令真正逻辑外面(洋葱) |
| 典型用途 | 审计、埋点、监控 | 审批、限流、token 注入 |
扩展 6:On — 生命周期(进程级)
它是什么
On 注册进程级生命周期处理器:
-
Startup:CLI 启动期、所有 plugin 装好、命令树定型后只跑一次。返错或 panic = 整树罢工(fail-closed)。
-
Shutdown:
cmd.Execute返回前跑。2 秒硬超时,错误只记录不上抛。
常用于初始化连接池、退出前刷盘。
怎么用
.On(platform.Startup, "init",
func(ctx context.Context, lc *platform.LifecycleContext) error {
return auditBuf.Open(ctx) // 启动期:加载审计 buffer
}).
.On(platform.Shutdown, "flush",
func(ctx context.Context, lc *platform.LifecycleContext) error {
return auditBuf.Flush(ctx) // 退出前:刷盘
})
Startup 失败的影响: 任何命令都会拿到下面这个 envelope,直到把 Startup 错误修复。
{
"ok": false,
"error": {
"type": "plugin_lifecycle",
"message": "lifecycle hook \"secaudit.init\" failed: connect: connection refused",
"detail": {
"reason_code": "lifecycle_failed",
"hook_name": "secaudit.init",
"event": "startup"
}
}
}
LifecycleContext 字段
| 字段 | 含义 |
|---|---|
Event LifecycleEvent |
当前事件:Startup / Shutdown。一个 handler 注册多个 event 时用来分流 |
Err error |
仅 Shutdown 有意义:cmd.Execute() 返回的命令错误。可据此区分"正常退出"与"异常退出" |
扩展 1 及扩展 2 完整示例
将 Credential + Transport 两个基础扩展组合在一起的 wrapper main:
package main
import (
"os"
"github.com/larksuite/cli/cmd"
// 自定义扩展(写在前面 = 凭证优先级更高)
_ "my-cli/mycred" // 自定义凭证来源
_ "my-cli/tracing" // HTTP 请求拦截
// 原版 CLI 兜底
_ "github.com/larksuite/cli/extension/credential/env"
)
func main() {
os.Exit(cmd.Execute())
}
对比原版 CLI 的 main.go,只多了 2 行 import。业务代码 cmd.Execute() 完全不变,编译出来就是一个功能完整的增强版 CLI。
项目结构
my-cli/
├── go.mod # require github.com/larksuite/cli v1.0.6
├── main.go # Wrapper main
├── mycred/
│ └── mycred.go # Credential: 自定义凭证来源
└── tracing/
└── tracing.go # Transport: HTTP 请求拦截
构建和运行
mkdir my-cli && cd my-cli
go mod init my-cli
# 编写 main.go、mycred/mycred.go、tracing/tracing.go(见上方代码)
go get github.com/larksuite/cli@v1.0.6
go mod tidy
go build -o my-lark-cli .
# 验证凭证扩展(--dry-run 不发真实请求,只看用了哪组凭证)
./my-lark-cli api GET /open-apis/calendar/v4/calendars --dry-run
输出:
{
"appId": "cli_aXXXXXXXXXXXXXXXX",
"as": "user"
}
# 验证请求拦截(发送真实请求,查看日志输出)
./my-lark-cli api GET /open-apis/calendar/v4/calendars --as user
输出:
🚀 请求开始: GET https://open.feishu.cn/open-apis/authen/v1/user_info
✅ 请求完成: GET .../user_info 状态码=200 (耗时 644ms)
🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET .../calendars 状态码=200 (耗时 1070ms)
{ "code": 0, "data": { "calendar_list": [...] } }
两个扩展同时生效:Credential 提供了凭证和 Token,Transport 拦截并记录了每个请求。
附录 A:进阶能力-更精细化的 hook 能力
前面 Restrict / Observer / Wrap / On 都用 Builder 写法。Builder 其实是对下面底层接口的封装。需要在结构体上挂依赖(审批客户端、连接池)、或 Install 逻辑复杂时,直接实现接口更清晰。每个 plugin 必须实现 4 个方法:
type Plugin interface {
Name() string // 全局唯一,正则 ^[a-z0-9][a-z0-9-]*$,禁用 "."
Version() string // 任意字符串,仅用于诊断
Capabilities() Capabilities // 安全声明
Install(r Registrar) error // 启动期被调一次,在这里通过 r 注册具体能力
}
Capabilities 是安全契约,每次写新 plugin 必须明确填写:
type Capabilities struct {
Restricts bool // 我会不会调 r.Restrict(命令裁剪)?默认 false
FailurePolicy FailurePolicy // FailOpen(装失败就跳过)/ FailClosed(装失败整树 abort)
RequiredCLIVersion string // 例如 ">=1.0.0";不满足按 FailurePolicy 处理
}
硬性约束: Restricts=true 必须配 FailurePolicy=FailClosed。框架一致性检查不通过会无条件 abort(即使声明 FailOpen)——安全相关的 plugin 不应该被 FailOpen 静默跳过。
用 Builder 就不用操心这些:NewPlugin(...).Restrict(...) 会自动把 Restricts=true 和 FailClosed 配齐。
Builder vs 接口实现,怎么选:
-
简单 plugin(只挂 Observer / Wrap)→ 用 Builder,一行一个能力,零样板
-
持有外部依赖(审批客户端、连接池)→ 实现 interface 让依赖落在 struct 字段上更清晰
Builder 还提供 FailOpen() / FailClosed() 显式 setter:只挂 Observer / Wrap 不调 Restrict 的 plugin 默认 FailOpen,但你可能想强制 FailClosed(plugin 装失败整树罢工,保证审计不丢)。一旦调过 Restrict(),Builder 会强制 FailClosed,再调 FailOpen() 会在 Build() 阶段报错。
附录 B:错误对照表
所有命令拒绝都走 command_denied,detail.layer 区分来源——policy 为用户层 Rule,strict_mode 为内置 strict-mode 裁剪。
| 错误类型 | 错误码 | 含义 | 怎么修 |
|---|---|---|---|
plugin_install |
restricts_mismatch |
Restricts=true 但 FailurePolicy≠FailClosed | 改成 FailurePolicy: platform.FailClosed |
plugin_install |
invalid_capability |
FailurePolicy 不是 FailOpen/FailClosed,或 RequiredCLIVersion 格式非法 | 用合法枚举值;version 用单段 semver(≤3 段) |
plugin_install |
invalid_plugin_name |
Name 不符合 ^[a-z0-9][a-z0-9-]*$ |
改名(小写、连字符、不含点) |
plugin_install |
duplicate_plugin_name |
两个 plugin 用了同一个 Name | 改名让其中一个唯一 |
plugin_install |
plugin_name_panic |
Plugin.Name() 在调用中 panic | 排查 Name();不要做 I/O 或访问可能 nil 的字段 |
plugin_install |
capabilities_panic |
Plugin.Capabilities() panic | 同上 |
plugin_install |
capability_unmet |
RequiredCLIVersion 不满足 | 升级 CLI 或放宽约束 |
plugin_install |
install_panic |
Install 里 panic 了 | 排查 Install 函数体 |
plugin_install |
install_failed |
Install 返回非 nil 的 error | 看 envelope detail 里包裹的原始 error |
plugin_install |
invalid_rule |
Rule 字段非法(如 max_risk 不在枚举内、glob 解析失败) | 校验 Rule 字段;MaxRisk 用 read/write/high-risk-write |
plugin_install |
invalid_hook_name |
hookName 不符合 ^[a-z0-9][a-z0-9-]*$(不能含 ".") |
改名;框架自动加 plugin 名前缀 |
plugin_install |
duplicate_hook_name |
同一 plugin 注册了两个同名 hook | 改名让其唯一 |
plugin_install |
invalid_hook_registration |
Wrapper factory 返回 nil 或 selector 不合法 | 确保 Wrapper 构造返回非 nil Handler |
plugin_conflict |
multiple_restrict_plugins |
2+ 不同 plugin 都贡献 Rule(同一 plugin 多条不算冲突) | 只保留一个贡献 Rule 的 plugin |
plugin_lifecycle |
lifecycle_failed / lifecycle_panic |
Startup handler 返错 / panic | 排查 Startup 逻辑 |
command_denied |
domain_not_allowed |
命令不在 Allow 里 | 加进 Allow 或换条命令 |
command_denied |
command_denylisted |
命令命中 Deny 列表 | 从 Deny 移除或换命令 |
command_denied |
write_not_allowed |
写类命令风险超过 MaxRisk | 提高 MaxRisk 或不用该命令 |
command_denied |
risk_too_high |
命令风险超过 MaxRisk(非写类,保留位) | 提高 MaxRisk |
command_denied |
risk_not_annotated |
命令未标注 risk_level,Rule 默认 fail-closed。注:内置命令已全部标注(commit 5d9d438),该错误主要出现在第三方 plugin 注册的命令 |
给命令补 risk_level 注解,或 Rule 设 AllowUnannotated=true |
command_denied |
risk_invalid |
risk_level 拼错(如 wrtie),不在枚举内 | 改成合法枚举(错误信息含 did-you-mean 提示) |
command_denied |
identity_mismatch |
命令身份与 Rule.Identities 不交集 | 放宽 Identities 或换合适身份 |
command_denied |
no_matching_rule |
注册了多条 Rule,命令一条都没命中(单条时仍报各自具体 reason_code) | 放宽某条 Rule,或新增一条覆盖该命令的 Rule |
command_denied |
identity_not_supported |
strict-mode 层裁命令:命令支持的身份与当前 strict-mode 不交集 | 调整 config strict-mode 或选支持当前身份的命令 |
command_denied |
all_children_denied |
父组下所有子命令全部被拒,父节点也自动被裁(聚合) | 放宽该域的 Allow 或允许部分子命令 |
command_denied |
mixed_children_strict_mode |
父组下部分子命令被 strict-mode 裁;父节点聚合显示,detail.layer = strict_mode |
看具体子命令的拒绝详情;通常是 strict-mode 配置问题 |
command_denied |
mixed_children_policy |
父组下部分子命令被 user-layer policy 裁;父节点聚合显示,detail.layer = policy |
放宽该域的 Allow / Deny 或允许特定子命令 |
hook |
aborted |
Wrap 体内显式返回 *AbortError 短路 |
看 envelope detail 的 hook_name + reason |
hook |
panic |
Wrap 体内 panic 被框架兜底捕获 | 修复 Wrapper 代码;框架已 recover,不会污染外层 |